Skip to content

Refresh live search members selectively on prerender_html events#5459

Merged
habdelra merged 6 commits into
mainfrom
cs-11766-prerender-split-selective-per-member-refresh-matches-re-run
Jul 9, 2026
Merged

Refresh live search members selectively on prerender_html events#5459
habdelra merged 6 commits into
mainfrom
cs-11766-prerender-split-selective-per-member-refresh-matches-re-run

Conversation

@habdelra

@habdelra habdelra commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Background

When you have a search open in the app (say, a grid of card tiles in operator mode), that search is live: the realm notifies the browser when its contents change, and the search updates itself without a page reload.

Search results are mostly served as prerendered HTML — the server renders each card ahead of time and the search just displays that HTML, which is much cheaper than instantiating every card in the browser. Recently, producing that HTML was split out of indexing into its own background job: when a card changes, its searchable data is updated first (fast), and the HTML re-render follows afterwards on its own channel (slow — rendering is orders of magnitude more expensive). When the HTML lands, the realm sends the browser a prerender_html event listing which cards got fresh HTML.

Until now, an open live search reacted to that event by re-running the entire search: re-send the query, re-fetch every result, rebuild every row. That's always correct, but consider what actually changed: the set of cards matching the query didn't move at all — only the pictures of some of them are newer. If your search shows 200 cards and one of them was re-rendered, we re-fetched all 200.

What this PR does

On a prerender_html event, the live search now updates only the cards the event names, and only if their HTML is actually newer than what's on screen — instead of re-running the whole search.

To know "newer," every search result already carries two version numbers (called generations): one for its searchable data and one for its HTML. The event carries a generation too. So the resource can look at each row on screen and decide: is this card in the event's list, and is the event's generation higher than the HTML I'm holding? Only such rows need attention — often none do, and then the event costs nothing.

For each row that does need attention, the browser asks the server for just that one card's rendering, using standard HTTP caching: the request says "I have version 12:10 — anything newer?" (an If-None-Match header). The server answers either:

  • "No, that's still current" (304) — the row on screen is left completely untouched. This matters beyond saving bytes: if the user had interacted with that card and it had been upgraded to a fully live card instance, replacing the row would have knocked it back to static HTML.
  • "Yes, here's the new one" (200) — the fresh rendering is swapped into that row's slot. Every other row keeps its exact object identity, so nothing else re-renders. (If the new content turns out identical to what's on screen, the row object is kept too.)

Events can arrive while a refresh is already underway — another batch of HTML landing, or an ordinary index event, possibly for a different workspace the search spans. The queued work survives all of these: it is only discarded once it has actually been applied, and an index event arriving mid-refresh absorbs the pending HTML updates into its own (full) re-fetch rather than dropping them. Each invalidated card is tracked with its own generation, since generations are per-workspace counters and can't be compared across workspaces.

What still re-runs in full

  • Free-text searches (queries with a matches filter, i.e. "search cards containing the word pigeon"). These match against a text extraction that is produced by the render, so a prerender_html event genuinely can add or remove results — membership isn't stable. They keep the old behavior: re-run the whole search.
  • Searches that ask for card data only (no HTML). These run in a mode that hides cards whose rendering failed — and a render failure is announced by, precisely, a prerender_html event. So for these queries the event can change which cards appear at all, and only a full re-run picks that up.
  • Anything the one-card request can't express: paginated searches (we can't splice one row into a page-limited window and keep the totals honest), and queries selecting multiple renderings per card or individual card fields.
  • Any error along the way: if the one-card request fails or returns something unexpected, the resource falls back to the full re-run rather than leaving a stale row. The fallback is the previous behavior, so the worst case of this PR is exactly what happened before it on every event.

The older live-search resource that returns card instances deliberately keeps re-running on every prerender_html event, for the same reason as the card-data-only case above: it hides cards with render failures, so these events can change its results even though instances carry no HTML.

Where the changes live

  • packages/host/app/resources/search-entries.ts — the live-search resource: decides which path an event takes, performs the per-card refreshes, splices results in place.
  • packages/host/app/services/store.ts — new fetchCardEntry: the "give me this one card's rendering, unless mine is current" HTTP request. (The server endpoint it talks to already exists.)
  • packages/runtime-common — small helpers: detect a free-text filter anywhere in a query, read the requested format off a query, validate the one-card response shape.

Testing

Integration tests cover: an event refreshes only the named card and issues no full re-query; a 304 leaves the row object untouched; a card already up to date or not on screen causes no request at all; a card that matched before its HTML ever rendered gets upgraded; free-text and card-data-only queries still re-run in full; a failed one-card request falls back to the full re-run; events arriving mid-refresh — both another HTML batch and an index event on a different workspace — neither drop nor duplicate work; and a real (unstubbed) round trip against the server proving the version tag the browser reconstructs matches the server's, so the 304 path genuinely fires. Unit tests pin the helper that reads the requested format off a query, including malformed input.

🤖 Generated with Claude Code

A prerender_html event can't change a structured query's membership — only
its members' renderings. So instead of re-querying the whole search on every
such event, the entry-model live search now refreshes just the members the
event carries newer HTML for, each through a conditional card+html GET keyed
on the composite validator the member holds: a 304 keeps the current
rendering (and the member's object identity, so a hydrated row stays live), a
200 swaps in the fresh entry. Full-text (matches) queries still re-run in
full, since their membership is built from markdown. Paginated queries,
composite/sparse rendering selections, and any member that can't be refreshed
in isolation fall back to the coarse re-run, so nothing regresses.

The instances live search makes the same distinction: it skips a
prerender_html re-run for a structured query (instances carry no prerendered
HTML and their membership is final after the index pass) and keeps re-running
for a matches query.

- runtime-common: filterHasMatches / wireFilterHasMatches, a single-leaf
  htmlQuery -> (format, renderType) helper, and an isEntrySingleDocument guard.
- store: fetchCardEntry issues the conditional single-instance card+html GET.
- search-entries resource: carries each member's index/html generations,
  reconstructs the composite validator, and performs the per-member refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@habdelra
habdelra requested a review from Copilot July 9, 2026 18:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f99c52a35

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/host/app/resources/search-entries.ts Outdated
The search task consumed pendingSelectiveRefresh at task start, so a restart
that cancelled a selective refresh mid-GET (an event on another realm) lost
the queued invalidations with the cancelled run, and the stale member never
refreshed. The queue is now consumed only at the point its content is applied
or folded into the coarse re-run, so a cancelled run leaves it for the
replacement run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes host live-search behavior on prerender_html realm events by selectively refreshing only affected search members (and only when the event’s generation is newer), avoiding costly whole-search re-queries when membership is stable.

Changes:

  • Added selective per-member refresh to the host search-entries live resource, including generation tracking and conditional one-card card+html GETs with If-None-Match.
  • Added StoreService.fetchCardEntry() to fetch a single entry+rendering without hydrating it into the store, plus runtime-common helpers/guards to support the flow.
  • Updated live instance-search gating to ignore prerender_html for structured queries (still re-run for full-text matches), and expanded integration tests for the new behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/runtime-common/search-entry.ts Adds helpers to detect matches in wire filters and extract leaf htmlQuery rendering selection for one-card refresh.
packages/runtime-common/query.ts Adds filterHasMatches() to detect full-text queries for event gating decisions.
packages/runtime-common/index.ts Re-exports isEntrySingleDocument for host consumption.
packages/runtime-common/document-types.ts Adds isEntrySingleDocument() guard for validating the single-entry JSON:API shape.
packages/host/app/services/store.ts Adds fetchCardEntry() implementing conditional single-entry card+html / file-meta+html GET with ETag semantics.
packages/host/app/resources/search.ts Stops re-running instance searches on prerender_html unless the query uses matches.
packages/host/app/resources/search-entries.ts Implements queued/coalesced selective refresh on prerender_html, generation-aware candidate selection, and in-place replacement to preserve row identity.
packages/host/tests/integration/resources/search-entries-test.gts Adds integration coverage for selective refresh, 304 identity preservation, upgrade-from-empty-html, fallback behavior, and real ETag round-trip.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/runtime-common/search-entry.ts
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 49m 21s ⏱️ + 1m 59s
3 449 tests +3  3 434 ✅ +3  15 💤 ±0  0 ❌ ±0 
3 468 runs  +3  3 453 ✅ +3  15 💤 ±0  0 ❌ ±0 

Results for commit 8223d0b. ± Comparison against earlier commit f38fc14.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 4s ⏱️ -53s
1 798 tests ±0  1 798 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 877 runs  ±0  1 877 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 8223d0b. ± Comparison against earlier commit f38fc14.

habdelra and others added 2 commits July 9, 2026 15:44
- The instances live search keeps re-running on prerender_html events: its
  query mode excludes rows with an effective error, and a render error lands
  on the prerendered_html channel at-or-above the row's index generation, so
  membership can flip with no index event announcing it. (filterHasMatches,
  now unused, is removed.)
- An item-only entry fieldset takes the coarse re-run for the same reason —
  without the html branch the search runs the live-search projection, whose
  membership excludes render-errored rows.
- The selective refresh is computed by a pure helper and applied in the task
  body, whose awaits are cancellation points — a run cancelled by a restart
  can no longer splice a superseded result into the entries. The helper also
  stops issuing GETs once a newer run supersedes it.
- Queued invalidations track the highest generation per URL instead of one
  max across events; generations are per-realm counters and are never
  compared across realms.
- An equal-content 200 keeps the member's object identity, matching the
  coarse merge paths.
- htmlQueryRenderingSelection returns undefined for a format-less eq leaf
  (format unconstrained has no single ?format= spelling) and validates its
  input structurally — malformed wire data reads as "no selection" instead
  of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/host/app/resources/search-entries.ts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@habdelra
habdelra requested a review from a team July 9, 2026 20:27
…churn

- Any failure in the member-refresh pipeline — the GET, the stylesheet
  imports, or the rebuild — now falls back to the coarse re-run; previously a
  stylesheet-import rejection escaped the task and the member stayed stale
  with nothing surfaced.
- Generation stamps no longer break row identity: a row whose visible content
  is unchanged keeps its object (and its hydration) across every merge path,
  adopting the fresh stamps in place. The invalidation fan-out re-indexes
  dependents whose content is often byte-identical, so without this every
  edit remounted every dependent row.
- Clearing the query mid-refresh stops the remaining member GETs, same as
  teardown.
- A prerender_html event with no usable generation takes the coarse re-run.
- The test fixture echoes the true default htmlQuery (format only), and the
  stylesheet-failure test pins that its loader stub actually intercepted —
  the loader service replaces its loader instance on reset, so a stub
  installed on a stale instance silently no-ops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some interactive analysis of this with AI to explore the edge cases I could think of, and it seem solid.

@habdelra
habdelra merged commit aa33b2b into main Jul 9, 2026
101 of 102 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants